import { standardBooleanAttributes } from "../core/AttributeMarshallingMixin.js";
import { setInternalState } from "../core/dom.js";
import { templateFrom } from "../core/htmlLiterals.js";
import ReactiveElement from "../core/ReactiveElement.js";
import DelegateFocusMixin from "./DelegateFocusMixin.js";
import {
  defaultState,
  defaultTabIndex,
  firstRender,
  ids,
  nativeInternals,
  render,
  rendered,
  setState,
  shadowRoot,
  state,
  template,
} from "./internal.js";

const extendsKey = Symbol("extends");

const delegatedPropertySettersKey = Symbol("delegatedPropertySetters");

/* True if a standard element is focusable by default. */
/** @type {IndexedObject<boolean>} */
const focusableByDefault = {
  a: true,
  area: true,
  button: true,
  details: true,
  iframe: true,
  input: true,
  select: true,
  textarea: true,
};

/*
 * A set of events which, if fired by the inner standard element, should be
 * re-raised by the custom element.
 *
 * These are events which are spec'ed to NOT get retargetted across a Shadow DOM
 * boundary, organized by which element(s) raise the events. To properly
 * simulate these, we will need to listen for the real events, then re-raise a
 * simulation of the original event. For more information, see
 * https://www.w3.org/TR/shadow-dom/#h-events-that-are-not-leaked-into-ancestor-trees.
 *
 * It appears that we do *not* need to re-raise the non-bubbling "focus" and
 * "blur" events. These appear to be automatically re-raised as expected -- but
 * it's not clear why that happens.
 *
 * The list below is reasonably complete. It omits elements that cannot be
 * wrapped (see class notes above). Also, we haven't actually tried wrapping
 * every element in this list; some of the more obscure ones might not actually
 * work as expected, but it was easier to include them for completeness than
 * to actually verify whether or not the element can be wrapped.
 */
/** @type {IndexedObject<string[]>} */
const reraiseEvents = {
  address: ["scroll"],
  blockquote: ["scroll"],
  caption: ["scroll"],
  center: ["scroll"],
  dd: ["scroll"],
  dir: ["scroll"],
  div: ["scroll"],
  dl: ["scroll"],
  dt: ["scroll"],
  fieldset: ["scroll"],
  form: ["reset", "scroll"],
  frame: ["load"],
  h1: ["scroll"],
  h2: ["scroll"],
  h3: ["scroll"],
  h4: ["scroll"],
  h5: ["scroll"],
  h6: ["scroll"],
  iframe: ["load"],
  img: ["abort", "error", "load"],
  input: ["abort", "change", "error", "select", "load"],
  li: ["scroll"],
  link: ["load"],
  menu: ["scroll"],
  object: ["error", "scroll"],
  ol: ["scroll"],
  p: ["scroll"],
  script: ["error", "load"],
  select: ["change", "scroll"],
  tbody: ["scroll"],
  tfoot: ["scroll"],
  thead: ["scroll"],
  textarea: ["change", "select", "scroll"],
};

/*
 * Mouse events that should be disabled if the inner component is disabled.
 */
const mouseEventNames = [
  "click",
  "dblclick",
  "mousedown",
  "mouseenter",
  "mouseleave",
  "mousemove",
  "mouseout",
  "mouseover",
  "mouseup",
  "wheel",
];

// Keep track of which re-raised events should bubble.
/** @type {IndexedObject<boolean>} */
const eventBubbles = {
  abort: true,
  change: true,
  reset: true,
};

// Elements which are display: block by default.
// Source: https://developer.mozilla.org/en-US/docs/Web/HTML/Block-level_elements
const blockElements = [
  "address",
  "article",
  "aside",
  "blockquote",
  "canvas",
  "dd",
  "div",
  "dl",
  "fieldset",
  "figcaption",
  "figure",
  "footer",
  "form",
  "h1",
  "h2",
  "h3",
  "h4",
  "h5",
  "h6",
  "header",
  "hgroup",
  "hr",
  "li",
  "main",
  "nav",
  "noscript",
  "ol",
  "output",
  "p",
  "pre",
  "section",
  "table",
  "tfoot",
  "ul",
  "video",
];

// Standard attributes that don't have corresponding properties.
// These need to be delegated from the wrapper to the inner element.
const attributesWithoutProperties = [
  "accept-charset",
  "autoplay",
  "buffered",
  "challenge",
  "codebase",
  "colspan",
  "contenteditable",
  "controls",
  "crossorigin",
  "datetime",
  "dirname",
  "for",
  "formaction",
  "http-equiv",
  "icon",
  "ismap",
  "itemprop",
  "keytype",
  "language",
  "loop",
  "manifest",
  "maxlength",
  "minlength",
  "muted",
  "novalidate",
  "preload",
  "radiogroup",
  "readonly",
  "referrerpolicy",
  "rowspan",
  "scoped",
  "usemap",
];

const Base = DelegateFocusMixin(ReactiveElement);

/**
 * Wraps a standard HTML element so it can be extended
 *
 * The typical way to use this class is via its static `wrap` method.
 *
 * @inherits ReactiveElement
 * @mixes DelegateFocusMixin
 * @part inner - the inner standard HTML element
 */
class WrappedStandardElement extends Base {
  constructor() {
    super();
    /** @type {any} */ const cast = this;
    if (!this[nativeInternals] && cast.attachInternals) {
      this[nativeInternals] = cast.attachInternals();
    }
  }

  /**
   *
   * Wrapped standard elements need to forward some attributes to the inner
   * element in cases where the attribute does not have a corresponding
   * property. These attributes include those prefixed with "aria-", and some
   * unusual standard attributes like contenteditable. To handle those, this
   * class defines its own attributeChangedCallback.
   *
   * @ignore
   * @param {string} name
   * @param {string} oldValue
   * @param {string} newValue
   */
  attributeChangedCallback(name, oldValue, newValue) {
    const forwardAttribute = attributesWithoutProperties.indexOf(name) >= 0;
    if (forwardAttribute) {
      const innerAttributes = Object.assign({}, this[state].innerAttributes, {
        [name]: newValue,
      });
      this[setState]({ innerAttributes });
    } else {
      // Rely on the base attributeChangedCallback provided by
      // AttributeMarshallingMixin.
      super.attributeChangedCallback(name, oldValue, newValue);
    }
  }

  // Delegate method defined by HTMLElement.
  blur() {
    this.inner.blur();
  }

  // One HTMLElement we *don't* delegate is `click`. Generally speaking, a click
  // on the outer wrapper should behave the same as a click on the inner
  // element. Also, we want to ensure outside event listeners get a click event
  // when the click method is invoked. But a click on the inner element will
  // raise a click event that won't be re-raised by default across the shadow
  // boundary. The precise behavior seems to be slightly different in Safari
  // than other browsers, but it seems safer to not delegate click.
  //
  // click() {}

  // @ts-ignore
  get [defaultState]() {
    return Object.assign(super[defaultState], {
      innerAttributes: {},
    });
  }

  get [defaultTabIndex]() {
    return focusableByDefault[this.extends] ? 0 : -1;
  }

  /**
   * The tag name of the standard HTML element extended by this class.
   *
   * @returns {string}
   */
  get extends() {
    return this.constructor[extendsKey];
  }

  /**
   * Returns a reference to the inner standard HTML element.
   *
   * @type {HTMLElement}
   */
  get inner() {
    /** @type {any} */
    const result = this[ids] && this[ids].inner;
    if (!result) {
      /* eslint-disable no-console */
      console.warn(
        "Attempted to get an inner standard element before it was instantiated."
      );
    }
    return result;
  }

  static get observedAttributes() {
    // For our custom attributeChangedCallback to work, we need to observe
    // the attributes we want to forward.
    // @ts-ignore
    return [...super.observedAttributes, ...attributesWithoutProperties];
  }

  [render](/** @type {ChangedFlags} */ changed) {
    super[render](changed);

    const inner = this.inner;
    if (this[firstRender]) {
      // Listen for any events raised by the inner element which will not
      // automatically be retargetted across the Shadow DOM boundary, and
      // re-raise those events when they happen.
      const eventNames = reraiseEvents[this.extends] || [];
      eventNames.forEach((eventName) => {
        inner.addEventListener(eventName, () => {
          const event = new Event(eventName, {
            bubbles: eventBubbles[eventName] || false,
          });
          this.dispatchEvent(event);
        });
      });

      // If inner element can be disabled, then listen to mouse events on the
      // *outer* element and absorb them if the inner element is disabled.
      // Without this, a mouse event like a click on the inner disabled element
      // would be treated as a click on the outer element. Someone listening to
      // clicks on the outer element would get a click event, even though the
      // overall element is supposed to be disabled.
      if ("disabled" in inner) {
        mouseEventNames.forEach((eventName) => {
          this.addEventListener(eventName, (event) => {
            if (/** @type {any} */ (inner).disabled) {
              event.stopImmediatePropagation();
            }
          });
        });
      }
    }

    if (changed.tabIndex) {
      inner.tabIndex = this[state].tabIndex;
    }

    if (changed.innerAttributes) {
      // Forward attributes to the inner element.
      // See notes at attributeChangedCallback.
      const { innerAttributes } = this[state];
      for (const name in innerAttributes) {
        applyAttribute(inner, name, innerAttributes[name]);
      }
    }

    // Forward delegated properties to the inner element.
    this.constructor[delegatedPropertySettersKey].forEach((property) => {
      if (changed[property]) {
        const value = this[state][property];

        // Inner selection properties needed to be handled specially.
        // See TrackTextSelectionMixin.
        const specialCase =
          (property === "selectionEnd" || property === "selectionStart") &&
          value === null;
        if (!specialCase) {
          inner[property] = value;
        }
      }
    });
  }

  [rendered](/** @type {ChangedFlags} */ changed) {
    super[rendered](changed);

    // Apply disabled state.
    if (changed.disabled) {
      const { disabled } = this[state];
      if (disabled !== undefined) {
        setInternalState(this, "disabled", disabled);
      }
    }
  }

  /**
   * The template copied into the shadow tree of new instances of this element.
   *
   * The default value of this property is a template that includes an instance
   * the standard element being wrapped, with a `<slot>` element inside that
   * to pick up the element's light DOM content. For example, if you wrap an
   * `<a>` element, then the default template will look like:
   *
   *     <template>
   *       <style>
   *       :host {
   *         display: inline-block;
   *       }
   *       </style>
   *       <a id="inner">
   *         <slot></slot>
   *       </a>
   *     </template>
   *
   * The `display` styling applied to the host will be `block` for elements that
   * are block elements by default, and `inline-block` (not `inline`) for other
   * elements.
   *
   * If you'd like the template to include other elements, then override this
   * property and return a template of your own. The template should include an
   * instance of the standard HTML element you are wrapping, and the ID of that
   * element should be "inner".
   *
   * @type {(string|HTMLTemplateElement)}
   */
  get [template]() {
    const display = blockElements.includes(this.extends)
      ? "block"
      : "inline-block";
    const tag = this.extends;
    return templateFrom.html`
      <style>
        :host {
          display: ${display}
        }
        
        [part~="inner"] {
          box-sizing: border-box;
          height: 100%;
          width: 100%;
        }
      </style>
      <${tag} id="inner" part="inner ${tag}">
        <slot></slot>
      </${tag}>
    `;
  }

  /**
   * Creates a class that wraps a standard HTML element.
   *
   * Note that the resulting class is a subclass of WrappedStandardElement, not
   * the standard class being wrapped. E.g., if you call
   * `WrappedStandardElement.wrap('a')`, you will get a class whose shadow tree
   * will include an anchor element, but the class will *not* inherit from
   * HTMLAnchorElement.
   *
   * @static
   * @param {string} extendsTag - the standard HTML element tag to extend
   */
  static wrap(extendsTag) {
    // Create the new class.
    /** @type {Constructor<WrappedStandardElement>} */
    class Wrapped extends WrappedStandardElement {}

    // Indicate which tag it wraps.
    /** @type {any} */ (Wrapped)[extendsKey] = extendsTag;

    // Create getter/setters that delegate to the wrapped element.
    const element = document.createElement(extendsTag);
    defineDelegates(Wrapped, Object.getPrototypeOf(element));

    return Wrapped;
  }
}

/**
 * Update the given attribute on an element.
 *
 * Passing a non-null `value` acts like a call to `setAttribute(name, value)`.
 * If the supplied `value` is nullish, this acts like a call to
 * `removeAttribute(name)`.
 *
 * @private
 * @param {HTMLElement} element
 * @param {string} name
 * @param {string} value
 */
export function applyAttribute(element, name, value) {
  if (standardBooleanAttributes[name]) {
    // Boolean attribute
    if (typeof value === "string") {
      element.setAttribute(name, "");
    } else if (value === null) {
      element.removeAttribute(name);
    }
  } else {
    // Regular string-valued attribute
    if (value != null) {
      element.setAttribute(name, value.toString());
    } else {
      element.removeAttribute(name);
    }
  }
}

/**
 * Create a delegate for the method or property identified by the descriptor.
 *
 * @private
 * @param {string} name
 * @param {PropertyDescriptor} descriptor
 */
function createDelegate(name, descriptor) {
  if (typeof descriptor.value === "function") {
    if (name !== "constructor") {
      return createMethodDelegate(name, descriptor);
    }
  } else if (
    typeof descriptor.get === "function" ||
    typeof descriptor.set === "function"
  ) {
    return createPropertyDelegate(name, descriptor);
  }
  return null;
}

/**
 * Create a delegate for the method identified by the descriptor.
 *
 * @private
 * @param {string} name
 * @param {PropertyDescriptor} descriptor
 */
function createMethodDelegate(name, descriptor) {
  const value = function (/** @type {any[]} */ ...args) {
    // @ts-ignore
    this.inner[name](...args);
  };
  const delegate = {
    configurable: descriptor.configurable,
    enumerable: descriptor.enumerable,
    value,
    writable: descriptor.writable,
  };
  return delegate;
}

/**
 * Create a delegate for the property identified by the descriptor.
 *
 * @private
 * @param {string} name
 * @param {PropertyDescriptor} descriptor
 */
function createPropertyDelegate(name, descriptor) {
  /** @type {PlainObject} */
  const delegate = {
    configurable: descriptor.configurable,
    enumerable: descriptor.enumerable,
  };
  if (descriptor.get) {
    delegate.get = function () {
      return getInnerProperty(/** @type {any} */ (this), name);
    };
  }
  if (descriptor.set) {
    delegate.set = function (/** @type {any} */ value) {
      setInnerProperty(/** @type {any} */ (this), name, value);
    };
  }
  if (descriptor.writable) {
    delegate.writable = descriptor.writable;
  }
  return delegate;
}

/**
 * Define delegates for the given class for each property/method on the
 * indicated prototype.
 *
 * @private
 * @param {Constructor<Object>} cls
 * @param {Object} prototype
 */
function defineDelegates(cls, prototype) {
  const names = Object.getOwnPropertyNames(prototype);
  cls[delegatedPropertySettersKey] = [];
  names.forEach((name) => {
    const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
    if (!descriptor) {
      return;
    }
    const delegate = createDelegate(name, descriptor);
    if (delegate) {
      Object.defineProperty(cls.prototype, name, delegate);
      if (delegate.set) {
        cls[delegatedPropertySettersKey].push(name);
      }
    }
  });
}

/**
 * Return the value of the named property on the inner standard element.
 *
 * @private
 * @param {ReactiveElement} element
 * @param {string} name
 */
function getInnerProperty(element, name) {
  // If we haven't rendered yet, use internal state value. Once we've
  // rendered, we get the value from the wrapped element itself. Return our
  // concept of the current property value from state. If the property hasn't
  // been defined, however, get the current value of the property from the
  // inner element.
  //
  // This is intended to support cases like an anchor element. If someone sets
  // `href` on a wrapped anchor, we'll know the value of `href` from state,
  // but we won't know the value of href-dependent calculated properties like
  // `protocol`. Using two sources of truth (state and the inner element)
  // seems fragile, but it's unclear how else to handle this without
  // reimplementing all HTML property interactions ourselves.
  //
  // This arrangement also means that, if an inner element property can change
  // in response to user interaction (e.g., an input element's value changes
  // as the user types), the component must listen to suitable events on the
  // inner element and update its state accordingly.
  const value = element[state][name];
  return value || (element[shadowRoot] && element.inner[name]);
}

/**
 * Set the named property on the inner standard element.
 *
 * @private
 * @param {ReactiveElement} element
 * @param {string} name
 * @param {any} value
 */
function setInnerProperty(element, name, value) {
  // We normally don't check an existing state value before calling[setState],
  // relying instead on[setState] to do that check for us. However, we have
  // dangers in this particular component of creating infinite loops.
  //
  // E.g., setting the tabindex attibute will call attributeChangedCallback,
  // which will set the tabIndex property, which will want to set state, which
  // will cause a render, which will try to reflect the current value of the
  // tabIndex property to the tabindex attribute, causing a loop.
  //
  // To avoid this, we check the existing value before updating our state.
  if (element[state][name] !== value) {
    element[setState]({ [name]: value });
  }
}

export default WrappedStandardElement;
